Analyzers and Code Fixes
The Masterly.NonEmptyList package ships Roslyn analyzers, code fixes and the [NonEmptyCollection] source generator. They are active in every project that references the package. There is nothing else to install. The analyzers catch what the type system cannot: an empty literal that always throws, a LINQ call that hides a guarantee the type already gives you, and, if you opt in, a throwing LINQ call on a sequence nobody has proven non-empty.
// orders is an IEnumerable<Order> from your application
NonEmptyList<int> xs = []; // NEL001 error: always throws
var first = xs.First(); // NEL003: use xs.Head
if (xs.Count == 0) { } // NEL004: always false
var top = orders.Max(o => o.Total); // NEL005 (opt in): 'orders' is not proven non-empty
Rules at a glance
| Rule | Catches | Default severity | On by default | Code fix |
|---|---|---|---|---|
| NEL001 | [] assigned to a non-empty type | Error | yes | no |
| NEL002 | a non-empty factory called with a provably empty argument | Error | yes | no |
| NEL003 | a throwing LINQ operator where a total member exists | Info | yes | yes |
| NEL004 | an emptiness check the type already decides | Info | yes | yes, except for DefaultIfEmpty |
| NEL005 | a throwing LINQ operator on a sequence not proven non-empty | Warning | no | yes |
| CS1503, CS0029, CS0266, CS1662 | v3 migration: a non-empty list used where a List<T> is required | compiler error | yes |
All rules are in the category Masterly.NonEmptyList. Every fix supports Fix all in document, project or solution. The generator has its own diagnostics, NEL101 to NEL104, listed at the end.
Configuring severity
Change any rule's severity in .editorconfig:
[*.cs]
# Make the total-member hint a warning
dotnet_diagnostic.NEL003.severity = warning
# Turn the redundant-check hint off
dotnet_diagnostic.NEL004.severity = none
# Opt in to the unproven-sequence rule
dotnet_diagnostic.NEL005.severity = warning
The values are the standard ones: error, warning, suggestion, silent and none. To silence one occurrence, use #pragma warning disable NEL003 and #pragma warning restore NEL003 around it.
Enabling NEL005. NEL005 is disabled by default. Setting a severity for it in .editorconfig is what turns it on; warning is the intended level. Use error to fail the build once the count reaches zero. Without that line, the rule never runs.
Empty collection expression assigned to a non-empty type
NEL001, error. NonEmptyList<int> xs = []; compiles, because the collection builder accepts any span, but it always throws ArgumentException at run time. The analyzer reports it at compile time instead.
It applies wherever the collection expression's target type is one of the library's non-empty types, or derives from one: NonEmptyList<T>, ImmutableNonEmptyList<T>, ConstrainedList<T, TConstraint>, NonEmptySet<T>, and the classes generated by the [NonEmptyCollection] generator (see Domain collections). That includes arguments passed to parameters of those types.
NonEmptyList<int> xs = []; // NEL001
ConstrainedList<int, AtLeast<Two>> pair = []; // NEL001
Process([]); // NEL001 when Process takes a NonEmptyList<int>
NonEmptyList<int> xs = [0]; // give it an element
List<int> maybeEmpty = []; // or use a type that may be empty
Message: An empty collection expression can never produce a 'NonEmptyList<int>'; this throws at run time.
Only a literally empty [] is reported. [1, 2] and spreads such as [.. other] are not.
Non-empty factory called with an empty argument
NEL002, error. A factory or constructor receives a sequence the analyzer can prove is always empty, so the call always throws.
var a = NonEmptyList.From(Array.Empty<int>()); // NEL002
var b = new List<int>().ToNonEmptyList(); // NEL002
var c = new NonEmptyList<int>(new List<int>()); // NEL002
var d = NonEmptySet.From(new HashSet<int>()); // NEL002
var e = "".ToNonEmptyList(); // NEL002: a string is an IEnumerable<char>
// source is an IEnumerable<int> from your application
var a = NonEmptyList.From(source); // fine: not provably empty
if (source.TryToNonEmptyList(out NonEmptyList<int>? list))
{
// use list
}
Message: 'NonEmptyList.From' is called with an argument that is always empty; this throws at run time.
Calls that are checked. Methods named From, FromSpan, Create, ToNonEmptyList, ToConstrainedList, ToNonEmptySet and ToNonEmptyDictionary on the library's NonEmptyList, ImmutableNonEmptyList, ConstrainedList, NonEmptySet, NonEmptyDictionary and EventStream types and its LINQ and constrained-list extension classes. Also any new of a non-empty type whose single argument goes to an IEnumerable<T> constructor parameter.
Arguments that are provably empty.
| Expression | Example |
|---|---|
| empty collection expression | [] |
Empty factories | Array.Empty<T>(), Enumerable.Empty<T>() |
static Empty members of immutable collections | ImmutableArray<T>.Empty, ImmutableList<T>.Empty |
| empty strings | "", string.Empty |
| zero-length or empty arrays | new T[0], new T[] { } |
| a new, empty mutable collection | new List<T>(), new HashSet<T>(), new Dictionary<TKey, TValue>(), new Queue<T>(), new Stack<T>(), new LinkedList<T>(), new SortedSet<T>(), new Collection<T>(), new ObservableCollection<T>(), and target-typed new() |
A collection created with constructor arguments, such as new List<int>(source) or new List<int>(capacity: 4) { 1 }, or with initializer elements, is not reported. Neither is new int[n].
Use the total member instead of a throwing LINQ operator
NEL003, info, with code fix. On a non-empty list, First(), Last() and Aggregate(f) carry an "empty sequence" exception path that the type has already ruled out. The total members Head, Last and Reduce say so in the code.
// xs is a NonEmptyList<int>
int first = xs.First(); // NEL003
int maybe = xs.FirstOrDefault(); // NEL003
int last = xs.Last(); // NEL003
int total = xs.Aggregate((a, b) => a + b); // NEL003
int count = xs.Count(); // NEL003
int first = xs.Head;
int maybe = xs.Head;
int last = xs.Last;
int total = xs.Reduce((a, b) => a + b);
int count = xs.Count;
| Call | Replacement | Reported on |
|---|---|---|
First(), FirstOrDefault() | Head | non-empty lists: NonEmptyList<T>, ImmutableNonEmptyList<T>, ConstrainedList<T, C>, ordered lists, groupings and derived classes |
Last(), LastOrDefault() | Last | the same |
Aggregate(f) | Reduce(f), keeping the argument | the same |
Count(), LongCount() | Count | the library's non-empty types that have a Count property, including NonEmptySet<T>; not the lazy NonEmptyEnumerable<T>, which has none |
Message: 'First()' on a non-empty list is total; use 'Head' instead, which the type guarantees cannot throw. The fix is titled Use 'Head'.
Overloads with a predicate or a seed, such as xs.First(x => x > 1) and xs.Aggregate(0, (acc, x) => acc + x), are not reported: they can still fail or have no total replacement. Calls on ordinary collections such as List<T> are not reported either.
LongCount() returns long and Count is an int. Where the result was assigned to var, the variable's type changes after the fix.
Redundant emptiness check on a non-empty list
NEL004, info, with code fix. A non-empty list is never empty, so checks such as Any() and Count == 0 always give the same answer. The fix replaces the expression with true or false, which leaves an obvious dead branch for you to delete. If emptiness really is possible at that point, the variable has the wrong type: widen it instead.
// xs is a NonEmptyList<Order>
if (!xs.Any()) // NEL004: xs.Any() is always true
return;
if (xs.Count == 0) // NEL004: always false
throw new InvalidOperationException("No orders");
// The checks are gone; xs.Head is safe without them
Order first = xs.Head;
| Expression | Always |
|---|---|
xs.Any() | true |
xs.Count == 0, xs.Count <= 0, xs.Count < 1 | false |
xs.Count != 0, xs.Count > 0, xs.Count >= 1 | true |
the mirrored forms, such as 0 < xs.Count or 1 > xs.Count | the same verdict |
xs.DefaultIfEmpty(), xs.DefaultIfEmpty(value) | the list itself (reported, no automatic fix) |
The comparisons work with the Count and Length properties and with Count() and LongCount(), against any constant, on any of the library's non-empty types. Checks that the type does not decide, such as xs.Count == 5, xs.Count > 1 or xs.Any(x => x.IsPaid), are not reported.
Message: 'xs.Count == 0' is always false for a non-empty list. The fix is titled Replace with 'false'.
Sequence is not proven non-empty
NEL005, warning, disabled by default, with code fix. This rule looks the other way: at the plain IEnumerable<T> code that does not use the library yet. It reports First(), Last(), Single(), Max(), Min(), Average(), Aggregate(f), MaxBy(), MinBy() and ElementAt() on a sequence that is not proven non-empty, because each of them throws on an empty sequence. Enable it as shown in Configuring severity.
// scores is an IEnumerable<int>
int highest = scores.Max(); // NEL005
int lowest = Enumerable.Min(scores); // NEL005: the static form is checked too
using Masterly.NonEmptyList;
int highest = scores.ToNonEmptyList().Max();
int lowest = Enumerable.Min(scores.ToNonEmptyList());
Message: 'Max()' may throw because 'scores' is not proven non-empty; convert it with ToNonEmptyList() or handle the empty case.
The fix, Prove non-empty with ToNonEmptyList(), wraps the sequence and adds using Masterly.NonEmptyList; when it is missing. The empty case still throws, but now in one explicit place, ToNonEmptyList(), with an ArgumentException that says why. Where empty input is expected, handle it instead:
if (scores.TryToNonEmptyList(out NonEmptyList<int>? proven))
{
int highest = proven.Max(); // int, never throws
}
What counts as proven. The rule stays quiet when the receiver is:
- one of the library's non-empty types, or anything deriving from one, or a call that returns one;
- a collection literal with elements:
new[] { 1 },[1, 2],new List<int> { 1 }; Enumerable.RangeorEnumerable.Repeatwith a constant count greater than zero;- the result of
Prepend,AppendorDefaultIfEmpty; Select,OrderBy,OrderByDescending,ThenBy,ThenByDescending,Order,OrderDescending,Reverse,Distinct,ToList,ToArray,AsEnumerableorCastapplied to a proven receiver;ConcatorUnionwhere either side is proven.
Overloads that cannot throw are skipped. Aggregate with a seed is total. Max, Min, MaxBy, MinBy and Average return null instead of throwing when the result type is a reference type or a Nullable<T>, so names.MaxBy(n => n.Length) on strings and values.Select(x => (int?)x).Max() are not reported.
Turn NEL005 on for one project, fix or prove the reported calls, then raise it to error. It is the same approach teams used to drive nullable warnings to zero.
Migration fix for CS1503, CS0029, CS0266 and CS1662
In version 3, NonEmptyList<T> stopped deriving from List<T>. Code that passed a non-empty list where a List<T> is required no longer compiles. Wherever the compiler reports a failed conversion from a non-empty list to List<T>, as an argument (CS1503), an assignment (CS0029, CS0266) or a lambda return value (CS1662), a code fix offers .ToList().
void Save(List<int> ids) { }
// xs is a NonEmptyList<int>
Save(xs); // CS1503
List<int> copy = xs; // CS0029
Save(xs.ToList());
List<int> copy = xs.ToList();
The fix is titled Convert with ToList() (NonEmptyList no longer derives from List<T>). It is offered only when the expression is a non-empty list and the target is List<T>; other conversion errors are left alone. Use Fix all in solution to migrate a codebase in one step.
ToList() makes a copy. In 2.x the method received the same instance, so changes it made were visible to the caller. If a method relies on mutating the list it receives, change its parameter to IList<T> instead of applying the fix. When it only reads, IReadOnlyList<T> is the better parameter type and needs no copy. See Upgrading from 2.x.
Generator diagnostics
The [NonEmptyCollection] source generator reports these errors. When one is reported, nothing is generated for that class. See Domain collections.
NEL101: class is not partial
'{0}' is marked [NonEmptyCollection] but is not partial; add the 'partial' modifier so the generator can complete it
NEL102: class is nested
'{0}' is marked [NonEmptyCollection] but is nested in another type; move it to namespace scope
NEL103: invalid bounds
'{0}': MinCount must be at least 1 or '{0}': MaxCount must be 0 (unbounded) or at least MinCount
NEL104: class declares a base type
'{0}' declares a base type; the generator supplies NonEmptyList<{1}> as the base
Each diagnostic's help link in the IDE opens the matching section of this page.