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