From d78210a706550b7bc498160bcbf11779d2e26e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 23 Jul 2026 16:21:44 +0200 Subject: [PATCH 1/7] feat(analyser): add NetEvolve.Arguments.Analyser project Add a Roslyn analyzer and code-fix package (NEA0001-NEA0009) that promotes NetEvolve.Arguments throw-helper usage on every supported target framework, including those that predate the built-in CA1510/CA1511/CA1512/CA1513 analyzers. - NEA0001: ArgumentNullException.ThrowIfNull (mirrors CA1510) - NEA0002: ArgumentException.ThrowIfNullOrEmpty/ThrowIfNullOrWhiteSpace (mirrors CA1511) - NEA0003: ArgumentOutOfRangeException throw helpers, incl. combined-range ThrowIfOutOfRange (mirrors CA1512) - NEA0004: ArgumentException.ThrowIfDefault - NEA0005: ObjectDisposedException.ThrowIf (mirrors CA1513) - NEA0006: ArgumentException string-length throw helpers - NEA0007: ArgumentException collection-count throw helpers - NEA0008: ArgumentException.ThrowIfContainsWhiteSpace - NEA0009: ArgumentException.ThrowIfEmptyGuid Rules mirroring a built-in CA rule stay silent once the compilation already exposes the real BCL member, avoiding duplicate diagnostics. Wired the analyzer into NetEvolve.Arguments itself for dogfooding, and added per-rule documentation under docs/analysers. --- Arguments.slnx | 2 + Directory.Packages.props | 3 + docs/analysers/NEA0001.md | 93 ++++++ docs/analysers/NEA0002.md | 65 ++++ docs/analysers/NEA0003.md | 80 +++++ docs/analysers/NEA0004.md | 64 ++++ docs/analysers/NEA0005.md | 70 +++++ docs/analysers/NEA0006.md | 66 ++++ docs/analysers/NEA0007.md | 66 ++++ docs/analysers/NEA0008.md | 63 ++++ docs/analysers/NEA0009.md | 63 ++++ .../AnalyzerReleases.Shipped.md | 9 + .../AnalyzerReleases.Unshipped.md | 4 + .../ComparisonResult.cs | 28 ++ .../DiagnosticDescriptors.cs | 107 +++++++ .../NetEvolve.Arguments.Analyser.csproj | 22 ++ src/NetEvolve.Arguments.Analyser/README.md | 147 +++++++++ .../SyntaxHelpers.cs | 292 ++++++++++++++++++ .../ThrowIfContainsWhiteSpaceAnalyzer.cs | 133 ++++++++ ...hrowIfContainsWhiteSpaceCodeFixProvider.cs | 96 ++++++ .../ThrowIfCountAnalyzer.cs | 145 +++++++++ .../ThrowIfCountCodeFixProvider.cs | 105 +++++++ .../ThrowIfDefaultAnalyzer.cs | 114 +++++++ .../ThrowIfDefaultCodeFixProvider.cs | 93 ++++++ .../ThrowIfDisposedAnalyzer.cs | 91 ++++++ .../ThrowIfDisposedCodeFixProvider.cs | 95 ++++++ .../ThrowIfEmptyGuidAnalyzer.cs | 122 ++++++++ .../ThrowIfEmptyGuidCodeFixProvider.cs | 93 ++++++ .../ThrowIfLengthAnalyzer.cs | 131 ++++++++ .../ThrowIfLengthCodeFixProvider.cs | 105 +++++++ .../ThrowIfNullAnalyzer.cs | 119 +++++++ .../ThrowIfNullCodeFixProvider.cs | 138 +++++++++ .../ThrowIfNullOrEmptyAnalyzer.cs | 140 +++++++++ .../ThrowIfNullOrEmptyCodeFixProvider.cs | 98 ++++++ .../ThrowIfOutOfRangeAnalyzer.cs | 170 ++++++++++ .../ThrowIfOutOfRangeCodeFixProvider.cs | 107 +++++++ .../NetEvolve.Arguments.csproj | 3 + .../AnalyzerVerifier.cs | 151 +++++++++ ...volve.Arguments.Analyser.Tests.Unit.csproj | 19 ++ .../ThrowIfContainsWhiteSpaceAnalyzerTests.cs | 57 ++++ .../ThrowIfCountAnalyzerTests.cs | 63 ++++ .../ThrowIfDefaultAnalyzerTests.cs | 74 +++++ .../ThrowIfDisposedAnalyzerTests.cs | 101 ++++++ .../ThrowIfEmptyGuidAnalyzerTests.cs | 52 ++++ .../ThrowIfLengthAnalyzerTests.cs | 59 ++++ .../ThrowIfNullAnalyzerTests.cs | 221 +++++++++++++ .../ThrowIfNullOrEmptyAnalyzerTests.cs | 137 ++++++++ .../ThrowIfOutOfRangeAnalyzerTests.cs | 138 +++++++++ 48 files changed, 4414 insertions(+) create mode 100644 docs/analysers/NEA0001.md create mode 100644 docs/analysers/NEA0002.md create mode 100644 docs/analysers/NEA0003.md create mode 100644 docs/analysers/NEA0004.md create mode 100644 docs/analysers/NEA0005.md create mode 100644 docs/analysers/NEA0006.md create mode 100644 docs/analysers/NEA0007.md create mode 100644 docs/analysers/NEA0008.md create mode 100644 docs/analysers/NEA0009.md create mode 100644 src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md create mode 100644 src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md create mode 100644 src/NetEvolve.Arguments.Analyser/ComparisonResult.cs create mode 100644 src/NetEvolve.Arguments.Analyser/DiagnosticDescriptors.cs create mode 100644 src/NetEvolve.Arguments.Analyser/NetEvolve.Arguments.Analyser.csproj create mode 100644 src/NetEvolve.Arguments.Analyser/README.md create mode 100644 src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs create mode 100644 src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/AnalyzerVerifier.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/NetEvolve.Arguments.Analyser.Tests.Unit.csproj create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs 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..188831a --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md @@ -0,0 +1,9 @@ +## Release 1.0 + +### New Rules + +| Rule ID | Category | Severity | Notes | +| ------- | --------------- | -------- | -------------------------- | +| NEA0001 | Maintainability | Info | ThrowIfNullAnalyzer | +| NEA0002 | Maintainability | Info | ThrowIfNullOrEmptyAnalyzer | +| NEA0003 | Maintainability | Info | ThrowIfOutOfRangeAnalyzer | diff --git a/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..c5ad05f --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md @@ -0,0 +1,4 @@ +# Unshipped 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..f5b21c4 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ComparisonResult.cs @@ -0,0 +1,28 @@ +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 +{ + public ComparisonResult( + string helperName, + ExpressionSyntax valueExpression, + ExpressionSyntax? otherExpression, + ExpressionSyntax? otherExpression2 = null + ) + { + HelperName = helperName; + ValueExpression = valueExpression; + OtherExpression = otherExpression; + OtherExpression2 = otherExpression2; + } + + public string HelperName { get; } + + public ExpressionSyntax ValueExpression { get; } + + public ExpressionSyntax? OtherExpression { get; } + + 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..6b64b68 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/DiagnosticDescriptors.cs @@ -0,0 +1,107 @@ +namespace NetEvolve.Arguments.Analyser; + +using Microsoft.CodeAnalysis; + +internal static class DiagnosticDescriptors +{ + private const string HelpLinkBase = "https://github.com/dailydevops/arguments/blob/main/docs/analysers"; + + 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" + ); + + 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" + ); + + 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" + ); + + 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" + ); + + 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" + ); + + 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" + ); + + 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" + ); + + 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" + ); + + 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..fb243e8 --- /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..19fd30d --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs @@ -0,0 +1,292 @@ +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; + +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. + /// + 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); + } + + public static ExpressionSyntax Unwrap(ExpressionSyntax expression) + { + while (expression is ParenthesizedExpressionSyntax parenthesized) + { + expression = parenthesized.Expression; + } + + return expression; + } + + 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; + } + + 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); + } + + 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; + } + + 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; + } + + 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; + } + + public static bool IsNullLiteral(ExpressionSyntax expression) => + Unwrap(expression).IsKind(SyntaxKind.NullLiteralExpression); + + private static bool IsReferenceEqualsName(ExpressionSyntax expression) => + Unwrap(expression) switch + { + IdentifierNameSyntax { Identifier.Text: "ReferenceEquals" } => true, + MemberAccessExpressionSyntax { Name.Identifier.Text: "ReferenceEquals" } => true, + _ => false, + }; + + 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; + } + + public static bool IsDefaultLiteral(ExpressionSyntax expression) => + Unwrap(expression) switch + { + LiteralExpressionSyntax literal => literal.IsKind(SyntaxKind.DefaultLiteralExpression), + DefaultExpressionSyntax => true, + _ => false, + }; + + 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; + } + + private static bool AreSameReference(ExpressionSyntax left, ExpressionSyntax right) => AreEquivalent(left, right); + + 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..c90d803 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs @@ -0,0 +1,133 @@ +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 +{ + 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); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + if (!TryGetContainsWhiteSpaceTarget(ifStatement.Condition, out var argument) || argument is null) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ArgumentExceptionMetadataName, + context.CancellationToken + ) + ) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ThrowIfContainsWhiteSpace, + ifStatement.GetLocation(), + argument.ToString() + ) + ); + } + + 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; + } + + 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..0e2062f --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs @@ -0,0 +1,96 @@ +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 +{ + 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 + ); + } + + 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..1adc6ed --- /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 +{ + 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); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + if (!TryGetCountComparison(ifStatement.Condition, out var comparison) || comparison is null) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ArgumentExceptionMetadataName, + context.CancellationToken + ) + ) + { + 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) + ); + } + + 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; + } + + 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..da2719d --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs @@ -0,0 +1,105 @@ +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 + ); + } + + 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..6ef8a13 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs @@ -0,0 +1,114 @@ +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 +{ + 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); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + if (!TryGetDefaultCheckedExpression(ifStatement.Condition, out var argument) || argument is null) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ArgumentExceptionMetadataName, + context.CancellationToken + ) + ) + { + return; + } + + if (objectCreation.ArgumentList is null) + { + return; + } + + if (!SyntaxHelpers.IsSingleParamNameArgument(argument, objectCreation.ArgumentList)) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticDescriptors.ThrowIfDefault, ifStatement.GetLocation(), argument.ToString()) + ); + } + + 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..4085a4c --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs @@ -0,0 +1,93 @@ +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 +{ + 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 + ); + } + + 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..dc32d64 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs @@ -0,0 +1,91 @@ +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 +{ + 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); + } + + 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); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + var enclosingSymbol = context.SemanticModel.GetEnclosingSymbol( + ifStatement.SpanStart, + context.CancellationToken + ); + + if (enclosingSymbol is null || enclosingSymbol.IsStatic) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ObjectDisposedExceptionMetadataName, + context.CancellationToken + ) + ) + { + 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..56e188b --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs @@ -0,0 +1,95 @@ +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 +{ + 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 + ); + } + + 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..9ff2a1d --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs @@ -0,0 +1,122 @@ +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 +{ + 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); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + if (!TryGetEmptyGuidCheckedExpression(ifStatement.Condition, out var argument) || argument is null) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ArgumentExceptionMetadataName, + context.CancellationToken + ) + ) + { + return; + } + + if (objectCreation.ArgumentList is null) + { + return; + } + + if (!SyntaxHelpers.IsSingleParamNameArgument(argument, objectCreation.ArgumentList)) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticDescriptors.ThrowIfEmptyGuid, ifStatement.GetLocation(), argument.ToString()) + ); + } + + 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; + } + + 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..4631f0c --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs @@ -0,0 +1,93 @@ +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 +{ + 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 + ); + } + + 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..f73ed6c --- /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 +{ + 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); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + if (!TryGetLengthComparison(ifStatement.Condition, out var comparison) || comparison is null) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ArgumentExceptionMetadataName, + context.CancellationToken + ) + ) + { + 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) + ); + } + + 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; + } + + 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..4f73d47 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs @@ -0,0 +1,105 @@ +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 + ); + } + + 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..37ac824 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs @@ -0,0 +1,119 @@ +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 +{ + 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); + } + + 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); + } + + 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()) + ); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + if (!SyntaxHelpers.TryGetNullCheckedExpression(ifStatement.Condition, out var argument) || argument is null) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ArgumentNullExceptionMetadataName, + context.CancellationToken + ) + ) + { + return; + } + + if (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..40df67f --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs @@ -0,0 +1,138 @@ +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 +{ + 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 + ); + } + } + + 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); + } + + 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(); + } + + 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..df2407f --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs @@ -0,0 +1,140 @@ +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 +{ + 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); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + // ArgumentException.ThrowIfNullOrEmpty/ThrowIfNullOrWhiteSpace exist on the BCL since .NET 8; + // where they do, the built-in CA1511 analyzer already covers this pattern, so stay silent. + if (SyntaxHelpers.HasBuiltInMember(context.Compilation, ArgumentExceptionMetadataName, "ThrowIfNullOrEmpty")) + { + return; + } + + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + if (!TryGetStringCheck(ifStatement.Condition, out var argument, out var helperName) || argument is null) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ArgumentExceptionMetadataName, + context.CancellationToken + ) + ) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ThrowIfNullOrEmpty, + ifStatement.GetLocation(), + helperName, + argument.ToString() + ) + ); + } + + 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; + } + + 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..3e029b0 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs @@ -0,0 +1,98 @@ +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 + ); + } + + 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..b905058 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs @@ -0,0 +1,170 @@ +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 +{ + 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); + } + + 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); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (ifStatement.Else is not null) + { + return; + } + + if (!TryGetComparison(ifStatement.Condition, out var comparison) || comparison is null) + { + return; + } + + var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + if (objectCreation.ArgumentList is null || objectCreation.ArgumentList.Arguments.Count is 0 or > 3) + { + return; + } + + if ( + !SyntaxHelpers.IsExceptionType( + context.SemanticModel, + objectCreation, + ArgumentOutOfRangeExceptionMetadataName, + context.CancellationToken + ) + ) + { + return; + } + + var value = comparison.Value; + var args = value.OtherExpression2 is null + ? value.OtherExpression is null + ? value.ValueExpression.ToString() + : $"{value.ValueExpression}, {value.OtherExpression}" + : $"{value.ValueExpression}, {value.OtherExpression}, {value.OtherExpression2}"; + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ThrowIfOutOfRange, + ifStatement.GetLocation(), + value.HelperName, + args + ) + ); + } + + internal static bool TryGetComparison(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 + ) + { + var lessThanValue = SyntaxHelpers.Unwrap(lessThan.Left); + var greaterThanValue = SyntaxHelpers.Unwrap(greaterThan.Left); + + if ( + lessThanValue is not LiteralExpressionSyntax + && SyntaxHelpers.AreEquivalent(lessThanValue, greaterThanValue) + ) + { + comparison = new ComparisonResult( + "ThrowIfOutOfRange", + lessThanValue, + lessThan.Right, + greaterThan.Right + ); + return true; + } + } + + return false; + } + + 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; + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs new file mode 100644 index 0000000..064b3f6 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs @@ -0,0 +1,107 @@ +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 + ); + } + + 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; + var arguments = + value.OtherExpression2 is not null + ? new[] { value.ValueExpression, value.OtherExpression!, value.OtherExpression2 } + : value.OtherExpression is null ? new[] { value.ValueExpression } + : new[] { value.ValueExpression, value.OtherExpression }; + + 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..5179fb2 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs @@ -0,0 +1,57 @@ +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] + public async Task Analyze_WhenThrowingArgumentNullException_DoesNotReportDiagnostic() + { + const string source = """ + using System; + using System.Linq; + + class C + { + void M(string argument) + { + if (argument.Any(char.IsWhiteSpace)) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + 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..8a8f41e --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs @@ -0,0 +1,63 @@ +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] + public async Task Analyze_WhenThrowingArgumentNullException_DoesNotReportDiagnostic() + { + const string source = """ + using System; + using System.Collections.Generic; + + class C + { + void M(ICollection argument) + { + if (argument.Count > 100) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + 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..f531f5d --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs @@ -0,0 +1,74 @@ +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] + public async Task Analyze_WhenThrowingArgumentNullException_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(Guid argument) + { + if (argument.Equals(default)) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfDefaultAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [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..89e6329 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs @@ -0,0 +1,101 @@ +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] + 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..4c3f900 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs @@ -0,0 +1,52 @@ +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] + 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..2d901dd --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs @@ -0,0 +1,59 @@ +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] + public async Task Analyze_WhenThrowingArgumentOutOfRangeException_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string argument) + { + if (argument.Length > 100) throw new ArgumentOutOfRangeException(nameof(argument)); + } + } + """; + + 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..a0ddc03 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs @@ -0,0 +1,221 @@ +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)")] + 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")] + 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_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..87c36cf --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs @@ -0,0 +1,137 @@ +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 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..61d0fc5 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs @@ -0,0 +1,138 @@ +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_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);"); + } +} From 0ed1095eeff23fa5dcff7cccf0feff336c1a8556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 23 Jul 2026 16:22:31 +0200 Subject: [PATCH 2/7] style: apply formatter changes to analyser docs --- .../AnalyzerReleases.Shipped.md | 20 +++++++++++------ src/NetEvolve.Arguments.Analyser/README.md | 22 +++++++++---------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md index 188831a..dc179dd 100644 --- a/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md +++ b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md @@ -1,9 +1,15 @@ -## Release 1.0 +# Current functionality -### New Rules +## New Rules -| Rule ID | Category | Severity | Notes | -| ------- | --------------- | -------- | -------------------------- | -| NEA0001 | Maintainability | Info | ThrowIfNullAnalyzer | -| NEA0002 | Maintainability | Info | ThrowIfNullOrEmptyAnalyzer | -| NEA0003 | Maintainability | Info | ThrowIfOutOfRangeAnalyzer | +| 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/README.md b/src/NetEvolve.Arguments.Analyser/README.md index fb243e8..90550df 100644 --- a/src/NetEvolve.Arguments.Analyser/README.md +++ b/src/NetEvolve.Arguments.Analyser/README.md @@ -62,17 +62,17 @@ public class UserService 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)* | +| 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) From dde3e2b5ac189d2dacf700f4fb3b85838b750e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 23 Jul 2026 16:34:01 +0200 Subject: [PATCH 3/7] fix(analyser): restore Release-build compatibility Fix the Release-configuration build failures surfaced by CI: - Restore the exact unpadded pipe-table format AnalyzerReleases.*.md needs for RS2007 (SonarAnalyzer/editor auto-formatting had reflowed the tables into padded Markdown, which the release-tracking parser rejects). - Reword a comment that SonarAnalyzer's S125 misidentified as commented-out code. - Replace two nested ternaries flagged by S3358 with explicit if/else chains. --- .../AnalyzerReleases.Shipped.md | 26 +++++++++---------- .../AnalyzerReleases.Unshipped.md | 6 ++--- .../ThrowIfNullOrEmptyAnalyzer.cs | 4 +-- .../ThrowIfOutOfRangeAnalyzer.cs | 19 ++++++++++---- .../ThrowIfOutOfRangeCodeFixProvider.cs | 19 ++++++++++---- 5 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md index dc179dd..92cf743 100644 --- a/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md +++ b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md @@ -1,15 +1,15 @@ -# Current functionality +## Release 1.0 -## New Rules +### 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 | +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 index c5ad05f..c903787 100644 --- a/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md +++ b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md @@ -1,4 +1,4 @@ -# Unshipped Rules +### New Rules -| Rule ID | Category | Severity | Notes | -| ------- | --------------- | -------- | --------------------------------- | +Rule ID | Category | Severity | Notes +--------|----------|----------|------- diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs index df2407f..62c8321 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs @@ -32,8 +32,8 @@ public override void Initialize(AnalysisContext context) private static void OnCompilationStart(CompilationStartAnalysisContext context) { - // ArgumentException.ThrowIfNullOrEmpty/ThrowIfNullOrWhiteSpace exist on the BCL since .NET 8; - // where they do, the built-in CA1511 analyzer already covers this pattern, so stay silent. + // 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; diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs index b905058..625c1da 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs @@ -81,11 +81,20 @@ private static void Analyze(SyntaxNodeAnalysisContext context) } var value = comparison.Value; - var args = value.OtherExpression2 is null - ? value.OtherExpression is null - ? value.ValueExpression.ToString() - : $"{value.ValueExpression}, {value.OtherExpression}" - : $"{value.ValueExpression}, {value.OtherExpression}, {value.OtherExpression2}"; + 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( diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs index 064b3f6..ea021ae 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs @@ -76,11 +76,20 @@ root is null } var value = comparison.Value; - var arguments = - value.OtherExpression2 is not null - ? new[] { value.ValueExpression, value.OtherExpression!, value.OtherExpression2 } - : value.OtherExpression is null ? new[] { value.ValueExpression } - : new[] { value.ValueExpression, value.OtherExpression }; + 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( From 07bc1afc1708f1423df02ae073fd7dd1e28bc951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 23 Jul 2026 16:53:04 +0200 Subject: [PATCH 4/7] docs(analyser): add XML doc comments to every code element Add summary documentation (public and private members alike) across the analyser package's source files: descriptors, syntax helpers, and every analyzer/code-fix pair. test(analyser): broaden negative-path coverage Add negative-path tests for each rule (else clause present, wrong exception type, custom-message constructor, unrecognized condition shapes, combined-range mismatches) plus a positive case for the literal-paramName branch. Consolidated related negative cases into parametrized [Arguments] tests instead of one method per case. Raises analyser-project coverage from 84.3%/59.7% to 89.1%/73.3% (line/branch). --- .../ComparisonResult.cs | 9 ++ .../DiagnosticDescriptors.cs | 11 +++ .../SyntaxHelpers.cs | 67 ++++++++++++++ .../ThrowIfContainsWhiteSpaceAnalyzer.cs | 10 ++ ...hrowIfContainsWhiteSpaceCodeFixProvider.cs | 6 ++ .../ThrowIfCountAnalyzer.cs | 11 +++ .../ThrowIfCountCodeFixProvider.cs | 5 + .../ThrowIfDefaultAnalyzer.cs | 7 ++ .../ThrowIfDefaultCodeFixProvider.cs | 6 ++ .../ThrowIfDisposedAnalyzer.cs | 8 ++ .../ThrowIfDisposedCodeFixProvider.cs | 6 ++ .../ThrowIfEmptyGuidAnalyzer.cs | 10 ++ .../ThrowIfEmptyGuidCodeFixProvider.cs | 6 ++ .../ThrowIfLengthAnalyzer.cs | 11 +++ .../ThrowIfLengthCodeFixProvider.cs | 5 + .../ThrowIfNullAnalyzer.cs | 7 ++ .../ThrowIfNullCodeFixProvider.cs | 18 ++++ .../ThrowIfNullOrEmptyAnalyzer.cs | 13 +++ .../ThrowIfNullOrEmptyCodeFixProvider.cs | 6 ++ .../ThrowIfOutOfRangeAnalyzer.cs | 12 +++ .../ThrowIfOutOfRangeCodeFixProvider.cs | 5 + .../ThrowIfContainsWhiteSpaceAnalyzerTests.cs | 23 ++++- .../ThrowIfCountAnalyzerTests.cs | 20 +++- .../ThrowIfDefaultAnalyzerTests.cs | 40 +++++++- .../ThrowIfDisposedAnalyzerTests.cs | 34 +++++++ .../ThrowIfEmptyGuidAnalyzerTests.cs | 34 +++++++ .../ThrowIfLengthAnalyzerTests.cs | 23 ++++- .../ThrowIfNullOrEmptyAnalyzerTests.cs | 91 +++++++++++++++++++ .../ThrowIfOutOfRangeAnalyzerTests.cs | 73 +++++++++++++++ 29 files changed, 563 insertions(+), 14 deletions(-) diff --git a/src/NetEvolve.Arguments.Analyser/ComparisonResult.cs b/src/NetEvolve.Arguments.Analyser/ComparisonResult.cs index f5b21c4..5a22e1b 100644 --- a/src/NetEvolve.Arguments.Analyser/ComparisonResult.cs +++ b/src/NetEvolve.Arguments.Analyser/ComparisonResult.cs @@ -5,6 +5,11 @@ namespace NetEvolve.Arguments.Analyser; /// 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, @@ -18,11 +23,15 @@ public ComparisonResult( 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 index 6b64b68..5ccdcd1 100644 --- a/src/NetEvolve.Arguments.Analyser/DiagnosticDescriptors.cs +++ b/src/NetEvolve.Arguments.Analyser/DiagnosticDescriptors.cs @@ -2,10 +2,13 @@ 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", @@ -17,6 +20,7 @@ internal static class DiagnosticDescriptors 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", @@ -28,6 +32,7 @@ internal static class DiagnosticDescriptors 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", @@ -39,6 +44,7 @@ internal static class DiagnosticDescriptors 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", @@ -50,6 +56,7 @@ internal static class DiagnosticDescriptors 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", @@ -61,6 +68,7 @@ internal static class DiagnosticDescriptors 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", @@ -72,6 +80,7 @@ internal static class DiagnosticDescriptors 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", @@ -83,6 +92,7 @@ internal static class DiagnosticDescriptors 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", @@ -94,6 +104,7 @@ internal static class DiagnosticDescriptors 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", diff --git a/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs b/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs index 19fd30d..a405f66 100644 --- a/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs +++ b/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs @@ -8,12 +8,17 @@ namespace NetEvolve.Arguments.Analyser; 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); @@ -21,6 +26,9 @@ public static bool HasBuiltInMember(Compilation compilation, string typeMetadata 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) @@ -31,6 +39,9 @@ public static ExpressionSyntax Unwrap(ExpressionSyntax 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) @@ -49,6 +60,12 @@ public static ExpressionSyntax Unwrap(ExpressionSyntax expression) 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, @@ -62,6 +79,13 @@ CancellationToken cancellationToken return exceptionType is not null && SymbolEqualityComparer.Default.Equals(typeInfo.Type, exceptionType); } + /// + /// 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; @@ -89,6 +113,14 @@ public static bool TryGetNullCheckedExpression(ExpressionSyntax condition, out E 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, @@ -178,6 +210,12 @@ when IsReferenceEqualsName(referenceEqualsInvocation.Expression): 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, @@ -214,9 +252,15 @@ out ExpressionSyntax? argument 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 { @@ -225,6 +269,9 @@ private static bool IsReferenceEqualsName(ExpressionSyntax expression) => _ => 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 ( @@ -239,6 +286,9 @@ public static bool IsZeroLiteral(ExpressionSyntax expression) 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 { @@ -247,6 +297,15 @@ public static bool IsDefaultLiteral(ExpressionSyntax expression) => _ => 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) @@ -285,8 +344,16 @@ is InvocationExpressionSyntax 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 index c90d803..7080f5c 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfContainsWhiteSpaceAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ArgumentExceptionMetadataName = "System.ArgumentException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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; @@ -72,6 +75,10 @@ private static void Analyze(SyntaxNodeAnalysisContext context) ); } + /// 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; @@ -113,6 +120,9 @@ is not InvocationExpressionSyntax 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 ( diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs index 0e2062f..20221f0 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs @@ -16,6 +16,7 @@ namespace NetEvolve.Arguments.Analyser; [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"; /// @@ -53,6 +54,11 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) ); } + /// 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, diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs index 1adc6ed..bb7457b 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfCountAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ArgumentExceptionMetadataName = "System.ArgumentException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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; @@ -73,6 +76,10 @@ private static void Analyze(SyntaxNodeAnalysisContext context) ); } + /// 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); @@ -117,6 +124,10 @@ internal static bool TryGetCountComparison(ExpressionSyntax condition, out Compa 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); diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs index da2719d..9edab6e 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs @@ -58,6 +58,11 @@ ifStatement is null ); } + /// 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, diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs index 6ef8a13..7dc464c 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfDefaultAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ArgumentExceptionMetadataName = "System.ArgumentException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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; @@ -78,6 +81,10 @@ private static void Analyze(SyntaxNodeAnalysisContext context) ); } + /// 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); diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs index 4085a4c..03b67a7 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs @@ -16,6 +16,7 @@ namespace NetEvolve.Arguments.Analyser; [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"; /// @@ -53,6 +54,11 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) ); } + /// 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, diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs index dc32d64..9afd44b 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfDisposedAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ObjectDisposedExceptionMetadataName = "System.ObjectDisposedException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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 @@ -42,6 +45,11 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) 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; diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs index 56e188b..b000f68 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs @@ -16,6 +16,7 @@ namespace NetEvolve.Arguments.Analyser; [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"; /// @@ -53,6 +54,11 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) ); } + /// 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, diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs index 9ff2a1d..869217b 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfEmptyGuidAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ArgumentExceptionMetadataName = "System.ArgumentException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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; @@ -78,6 +81,10 @@ private static void Analyze(SyntaxNodeAnalysisContext context) ); } + /// 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); @@ -112,6 +119,9 @@ internal static bool TryGetEmptyGuidCheckedExpression(ExpressionSyntax condition 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 diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs index 4631f0c..34d9edb 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs @@ -16,6 +16,7 @@ namespace NetEvolve.Arguments.Analyser; [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"; /// @@ -53,6 +54,11 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) ); } + /// 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, diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs index f73ed6c..e03fe6d 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfLengthAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ArgumentExceptionMetadataName = "System.ArgumentException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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; @@ -73,6 +76,10 @@ private static void Analyze(SyntaxNodeAnalysisContext context) ); } + /// 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); @@ -117,6 +124,10 @@ internal static bool TryGetLengthComparison(ExpressionSyntax condition, out Comp 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) diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs index 4f73d47..e6e6119 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs @@ -58,6 +58,11 @@ ifStatement is null ); } + /// 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, diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs index 37ac824..ba34bb0 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfNullAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ArgumentNullExceptionMetadataName = "System.ArgumentNullException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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 @@ -43,6 +46,8 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) 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; @@ -69,6 +74,8 @@ out var argument ); } + /// 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; diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs index 40df67f..8fe21ae 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs @@ -17,6 +17,7 @@ namespace NetEvolve.Arguments.Analyser; [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"; /// @@ -65,6 +66,11 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) } } + /// 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, @@ -92,6 +98,15 @@ root is null 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, @@ -124,6 +139,9 @@ semanticModel is null 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( diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs index 62c8321..e7b963e 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfNullOrEmptyAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ArgumentExceptionMetadataName = "System.ArgumentException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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 @@ -42,6 +45,8 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) 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; @@ -85,6 +90,11 @@ private static void Analyze(SyntaxNodeAnalysisContext context) ); } + /// 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, @@ -130,6 +140,9 @@ is not InvocationExpressionSyntax 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 { diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs index 3e029b0..cdb1432 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs @@ -57,6 +57,12 @@ ifStatement is null ); } + /// 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, diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs index 625c1da..98e5930 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs @@ -11,6 +11,7 @@ namespace NetEvolve.Arguments.Analyser; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class ThrowIfOutOfRangeAnalyzer : DiagnosticAnalyzer { + /// The fully-qualified metadata name of . private const string ArgumentOutOfRangeExceptionMetadataName = "System.ArgumentOutOfRangeException"; /// @@ -30,6 +31,8 @@ public override void Initialize(AnalysisContext context) 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, @@ -42,6 +45,8 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) 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; @@ -106,6 +111,13 @@ private static void Analyze(SyntaxNodeAnalysisContext context) ); } + /// + /// 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); diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs index ea021ae..7a376f9 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs @@ -58,6 +58,11 @@ ifStatement is null ); } + /// 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, diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs index 5179fb2..4f51953 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs @@ -35,17 +35,32 @@ void M(string argument) } [Test] - public async Task Analyze_WhenThrowingArgumentNullException_DoesNotReportDiagnostic() + [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) { - const string source = """ + var source = $$""" using System; using System.Linq; class C { - void M(string argument) + void M(string argument, char other) { - if (argument.Any(char.IsWhiteSpace)) throw new ArgumentNullException(nameof(argument)); + {{statement}} } } """; diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs index 8a8f41e..8b08666 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs @@ -41,9 +41,23 @@ void M(ICollection argument) } [Test] - public async Task Analyze_WhenThrowingArgumentNullException_DoesNotReportDiagnostic() + [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) { - const string source = """ + var source = $$""" using System; using System.Collections.Generic; @@ -51,7 +65,7 @@ class C { void M(ICollection argument) { - if (argument.Count > 100) throw new ArgumentNullException(nameof(argument)); + {{statement}} } } """; diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs index f531f5d..0945fce 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs @@ -29,16 +29,30 @@ void M(Guid argument) } [Test] - public async Task Analyze_WhenThrowingArgumentNullException_DoesNotReportDiagnostic() + [Arguments("if (argument.Equals(default)) throw new ArgumentNullException(nameof(argument));")] + [Arguments("if (argument.Equals(default)) throw new ArgumentException(nameof(argument), \"custom\");")] + [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) { - const string source = """ + var source = $$""" using System; class C { void M(Guid argument) { - if (argument.Equals(default)) throw new ArgumentNullException(nameof(argument)); + {{statement}} } } """; @@ -48,6 +62,26 @@ void M(Guid argument) _ = 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() { diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs index 89e6329..f4c6e02 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs @@ -47,6 +47,40 @@ static void M() _ = 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() { diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs index 4c3f900..b03fc3d 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs @@ -26,6 +26,40 @@ void M(Guid argument) _ = 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(nameof(argument), \"custom\");")] + [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() { diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs index 2d901dd..b172f9f 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs @@ -38,16 +38,31 @@ void M(string argument) } [Test] - public async Task Analyze_WhenThrowingArgumentOutOfRangeException_DoesNotReportDiagnostic() + [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) { - const string source = """ + var source = $$""" using System; class C { - void M(string argument) + void M(string argument, string other) { - if (argument.Length > 100) throw new ArgumentOutOfRangeException(nameof(argument)); + {{statement}} } } """; diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs index 87c36cf..1a201c3 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs @@ -87,6 +87,97 @@ void M(string? argument) _ = 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() { diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs index 61d0fc5..3f18bbb 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs @@ -135,4 +135,77 @@ void M(int argument) _ = 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(); + } } From 2d177cf9f9142bdfda081f5b5b811f5bddda8261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 23 Jul 2026 17:08:49 +0200 Subject: [PATCH 5/7] test(analyser): cover remaining SyntaxHelpers branches Add tests for parenthesized conditions, qualified ReferenceEquals, null != argument (both polarities), zero-argument/mismatched-nameof exception constructors, rejected coalesce shapes (wrong exception type, custom message), and a non-literal ArgumentOutOfRangeException bound. --- .../ThrowIfNullAnalyzerTests.cs | 68 +++++++++++++++++++ .../ThrowIfOutOfRangeAnalyzerTests.cs | 30 +++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs index a0ddc03..3584c9d 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs @@ -31,6 +31,9 @@ void M(string? argument) [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 = $$""" @@ -54,6 +57,7 @@ void M(string? argument) [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 = $$""" @@ -148,6 +152,70 @@ void M(string? argument) _ = 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() { diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs index 3f18bbb..afeab8b 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Arguments.Analyser.Tests.Unit; +namespace NetEvolve.Arguments.Analyser.Tests.Unit; public sealed class ThrowIfOutOfRangeAnalyzerTests { @@ -43,6 +43,34 @@ void M(int argument) _ = 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() { From f8fb88c694fab27e9e2c0b8f56683c7644020c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 23 Jul 2026 17:10:14 +0200 Subject: [PATCH 6/7] test(analyser): fix ArgumentException constructor argument order ArgumentException(string? message, string? paramName) takes the message first and the parameter name second (the reverse of ArgumentNullException). Two tests had the arguments swapped. --- .../ThrowIfDefaultAnalyzerTests.cs | 2 +- .../ThrowIfEmptyGuidAnalyzerTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs index 0945fce..b17bc62 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs @@ -30,7 +30,7 @@ void M(Guid argument) [Test] [Arguments("if (argument.Equals(default)) throw new ArgumentNullException(nameof(argument));")] - [Arguments("if (argument.Equals(default)) throw new ArgumentException(nameof(argument), \"custom\");")] + [Arguments("if (argument.Equals(default)) throw new ArgumentException(\"custom\", nameof(argument));")] [Arguments("if (argument.Equals(1)) throw new ArgumentException(nameof(argument));")] [Arguments( """ diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs index b03fc3d..f5705e0 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs @@ -28,7 +28,7 @@ void M(Guid argument) [Test] [Arguments("if (argument == Guid.Empty) throw new ArgumentNullException(nameof(argument));")] - [Arguments("if (argument == Guid.Empty) throw new ArgumentException(nameof(argument), \"custom\");")] + [Arguments("if (argument == Guid.Empty) throw new ArgumentException(\"custom\", nameof(argument));")] [Arguments("if (argument == Guid.NewGuid()) throw new ArgumentException(nameof(argument));")] [Arguments( """ From c02c9db44bd0524d247f68a335d68415eefab896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 23 Jul 2026 19:17:42 +0200 Subject: [PATCH 7/7] refactor(analyser): extract shared throw-shape guard, lower complexity Every rule's Analyze method repeated the same else-check / single-throw / exception-type guard chain. Extract it into SyntaxHelpers.TryGetThrownException and reuse it across all 9 analyzers, and split ThrowIfOutOfRangeAnalyzer's combined-range detection into its own method. Addresses CodeFactor's cyclomatic-complexity findings on ThrowIfOutOfRangeAnalyzer.Analyze/TryGetComparison, ThrowIfEmptyGuidAnalyzer.Analyze, ThrowIfNullAnalyzer.Analyze, and ThrowIfDefaultAnalyzer.Analyze, while also removing duplicated logic across the other analyzers. --- .../SyntaxHelpers.cs | 41 +++++++++ .../ThrowIfContainsWhiteSpaceAnalyzer.cs | 19 +--- .../ThrowIfCountAnalyzer.cs | 19 +--- .../ThrowIfDefaultAnalyzer.cs | 26 ++---- .../ThrowIfDisposedAnalyzer.cs | 19 +--- .../ThrowIfEmptyGuidAnalyzer.cs | 26 ++---- .../ThrowIfLengthAnalyzer.cs | 19 +--- .../ThrowIfNullAnalyzer.cs | 26 ++---- .../ThrowIfNullOrEmptyAnalyzer.cs | 19 +--- .../ThrowIfOutOfRangeAnalyzer.cs | 86 +++++++++---------- 10 files changed, 116 insertions(+), 184 deletions(-) diff --git a/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs b/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs index a405f66..638e389 100644 --- a/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs +++ b/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs @@ -79,6 +79,47 @@ CancellationToken cancellationToken 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. diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs index 7080f5c..754227c 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs @@ -37,29 +37,18 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - if (!TryGetContainsWhiteSpaceTarget(ifStatement.Condition, out var argument) || argument is null) { return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ArgumentExceptionMetadataName, - context.CancellationToken + context.CancellationToken, + out _ ) ) { diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs index bb7457b..df5445b 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs @@ -37,29 +37,18 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - if (!TryGetCountComparison(ifStatement.Condition, out var comparison) || comparison is null) { return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ArgumentExceptionMetadataName, - context.CancellationToken + context.CancellationToken, + out _ ) ) { diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs index 7dc464c..268963e 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs @@ -37,40 +37,24 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - if (!TryGetDefaultCheckedExpression(ifStatement.Condition, out var argument) || argument is null) { return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ArgumentExceptionMetadataName, - context.CancellationToken - ) + context.CancellationToken, + out var objectCreation + ) || objectCreation!.ArgumentList is null ) { return; } - if (objectCreation.ArgumentList is null) - { - return; - } - if (!SyntaxHelpers.IsSingleParamNameArgument(argument, objectCreation.ArgumentList)) { return; diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs index 9afd44b..22dbdc7 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs @@ -54,11 +54,6 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - var enclosingSymbol = context.SemanticModel.GetEnclosingSymbol( ifStatement.SpanStart, context.CancellationToken @@ -69,19 +64,13 @@ private static void Analyze(SyntaxNodeAnalysisContext context) return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ObjectDisposedExceptionMetadataName, - context.CancellationToken + context.CancellationToken, + out _ ) ) { diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs index 869217b..67d30c4 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs @@ -37,40 +37,24 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - if (!TryGetEmptyGuidCheckedExpression(ifStatement.Condition, out var argument) || argument is null) { return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ArgumentExceptionMetadataName, - context.CancellationToken - ) + context.CancellationToken, + out var objectCreation + ) || objectCreation!.ArgumentList is null ) { return; } - if (objectCreation.ArgumentList is null) - { - return; - } - if (!SyntaxHelpers.IsSingleParamNameArgument(argument, objectCreation.ArgumentList)) { return; diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs index e03fe6d..deb9ee3 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs @@ -37,29 +37,18 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - if (!TryGetLengthComparison(ifStatement.Condition, out var comparison) || comparison is null) { return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ArgumentExceptionMetadataName, - context.CancellationToken + context.CancellationToken, + out _ ) ) { diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs index ba34bb0..154da8e 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs @@ -80,40 +80,24 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - if (!SyntaxHelpers.TryGetNullCheckedExpression(ifStatement.Condition, out var argument) || argument is null) { return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ArgumentNullExceptionMetadataName, - context.CancellationToken - ) + context.CancellationToken, + out var objectCreation + ) || objectCreation!.ArgumentList is null ) { return; } - if (objectCreation.ArgumentList is null) - { - return; - } - if (!SyntaxHelpers.IsSingleParamNameArgument(argument, objectCreation.ArgumentList)) { return; diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs index e7b963e..e257718 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs @@ -51,29 +51,18 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - if (!TryGetStringCheck(ifStatement.Condition, out var argument, out var helperName) || argument is null) { return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ArgumentExceptionMetadataName, - context.CancellationToken + context.CancellationToken, + out _ ) ) { diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs index 98e5930..cf4e803 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs @@ -51,35 +51,21 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { var ifStatement = (IfStatementSyntax)context.Node; - if (ifStatement.Else is not null) - { - return; - } - if (!TryGetComparison(ifStatement.Condition, out var comparison) || comparison is null) { return; } - var throwStatement = SyntaxHelpers.GetSingleThrowStatement(ifStatement.Statement); - - if (throwStatement?.Expression is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - - if (objectCreation.ArgumentList is null || objectCreation.ArgumentList.Arguments.Count is 0 or > 3) - { - return; - } - if ( - !SyntaxHelpers.IsExceptionType( + !SyntaxHelpers.TryGetThrownException( + ifStatement, context.SemanticModel, - objectCreation, ArgumentOutOfRangeExceptionMetadataName, - context.CancellationToken + context.CancellationToken, + out var objectCreation ) + || objectCreation!.ArgumentList is null + || objectCreation.ArgumentList.Arguments.Count is 0 or > 3 ) { return; @@ -125,32 +111,7 @@ internal static bool TryGetComparison(ExpressionSyntax condition, out Comparison 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 - ) - { - var lessThanValue = SyntaxHelpers.Unwrap(lessThan.Left); - var greaterThanValue = SyntaxHelpers.Unwrap(greaterThan.Left); - - if ( - lessThanValue is not LiteralExpressionSyntax - && SyntaxHelpers.AreEquivalent(lessThanValue, greaterThanValue) - ) - { - comparison = new ComparisonResult( - "ThrowIfOutOfRange", - lessThanValue, - lessThan.Right, - greaterThan.Right - ); - return true; - } - } - - return false; + return TryGetCombinedRangeComparison(orExpression, out comparison); } if (condition is not BinaryExpressionSyntax binary) @@ -188,4 +149,37 @@ lessThanValue is not LiteralExpressionSyntax 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; + } }