1 /**
2 Provides functionality for validating function arguments.
3 
4 Authors: Tony J. Hudgins
5 Copyright: Copyright © 2019, Tony J. Hudgins
6 License: MIT
7 */
8 module ensure;
9 
10 import std.traits;
11 import std.typecons : Nullable, NullableRef;
12 import std.range : isInputRange, hasLength;
13 import std.format : format;
14 
15 private enum isValidRangeSpecifier( string spec ) =
16     spec !is null &&
17     spec.length == 2 &&
18     ( spec[0] == '(' || spec[0] == '[' ) &&
19     ( spec[1] == ')' || spec[1] == ']' );
20 
21 private enum supportsOperation( T, string op ) =
22     is( typeof( { bool _ = mixin( "T.init " ~ op ~ " T.init" ); } ) );
23 
24 version( unittest )
25 {
26     private
27     {
28         void mustThrow( E: Throwable, F )( F f )
29         {
30             try
31             {
32                 f();
33                 assert( false, "expression was supposed to throw but it didn't" );
34             } catch( E ) { }
35         }
36 
37         void mustNotThrow( E: Throwable, F )( F f )
38         {
39             try
40             {
41                 f();
42             }
43             catch( E )
44             {
45                 assert( false, "expression wasn't supposed to throw but it did (threw " ~ E.stringof ~ ")" );
46             }
47         }
48     }
49 }
50 
51 /++
52 A convenience wrapper for __traits( identifier, ... ).
53 
54 Authors: Tony J. Hudgins
55 Copyright: Copyright © 2019, Tony J. Hudgins
56 License: MIT
57 +/
58 enum nameof( alias symbol ) = __traits( identifier, symbol );
59 
60 @system unittest
61 {
62     struct Foo
63     {
64         int x;
65     }
66 
67     int a;
68     Foo b;
69 
70     // test locals
71     assert( nameof!a == "a" );
72 
73     // test members
74     assert( nameof!( b.x ) == "x" );
75 
76     // test types
77     assert( nameof!Foo == "Foo" );
78 
79     // test modules
80     assert( nameof!ensure == "ensure" );
81 }
82 
83 /++
84 Tests if a type can be null. Not related to std.typecons.Nullable!T.
85 
86 Authors: Tony J. Hudgins
87 Copyright: Copyright © 2019, Tony J. Hudgins
88 License: MIT
89 +/
90 enum isNullable( T ) = is( typeof( { Unqual!T _ = null; } ) );
91 
92 @system unittest
93 {
94     assert( isNullable!string );
95     assert( !isNullable!int );
96 }
97 
98 /++
99 Thrown for any validation errors.
100 
101 Authors: Tony J. Hudgins
102 Copyright: Copyright © 2019, Tony J. Hudgins
103 License: MIT
104 +/
105 final class EnsureException : Exception
106 {
107     private string _paramName;
108 
109     string paramName() const pure nothrow @trusted @property
110     {
111         return this._paramName;
112     }
113 
114     /++
115     Constructs a new EnsureException for a given parameter with a custom message.
116 
117     Params:
118         paramName = The name of the parameter being validated.
119         msg = The message to pass to the exception's constructor.
120         file = Used for passing the appropriate file name to the exception's constructor.
121         line = Used for passing the appropriate line number to the exception's constructor.
122         next = The next throwable object in the chain.
123 
124     Authors: Tony J. Hudgins
125     Copyright: Copyright © 2019, Tony J. Hudgins
126     License: MIT
127     +/
128     this( string paramName, string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null )
129     {
130         this._paramName = paramName;
131         auto newMsg = paramName is null || paramName.length == 0 ? msg : "%s: %s".format( paramName, msg );
132         super( newMsg, file, line, next );
133     }
134 }
135 
136 /++
137 Represents a single function parameter, including name and value, upon which all validation is performed.
138 
139 Authors: Tony J. Hudgins
140 Copyright: Copyright © 2019, Tony J. Hudgins
141 License: MIT
142 +/
143 struct Arg( T )
144 {
145     private {
146         string _paramName;
147         T _value;
148     }
149 
150     inout( string ) paramName() inout pure nothrow @trusted @property
151     {
152         return this._paramName;
153     }
154 
155     inout( T ) value() inout pure nothrow @trusted @property
156     {
157         return this._value;
158     }
159 
160     /// Default construction is not allowed.
161     this() @disable;
162 
163     private this( string paramName, T value )
164     {
165         this._paramName = paramName;
166         this._value = value;
167     }
168 
169     /++
170     Throws an EnsureException for the current parameter with the given message.
171 
172     Params:
173         msg = The message to pass to the exception's constructor.
174         file = Used for passing the appropriate file name to the exception's constructor.
175         line = Used for passing the appropriate line number to the exception's constructor.
176 
177     Throws: EnsureException whenever it's called.
178     Authors: Tony J. Hudgins
179     Copyright: Copyright © 2019, Tony J. Hudgins
180     License: MIT
181     +/
182     void throwWith( string msg, string file = __FILE__, size_t line = __LINE__ ) const @trusted
183     {
184         throw new EnsureException( this._paramName, msg, file, line );
185     }
186 }
187 
188 /++
189 Wrap a function argument to perform validation.
190 
191 Authors: Tony J. Hudgins
192 Copyright: Copyright © 2019, Tony J. Hudgins
193 License: MIT
194 +/
195 Arg!( typeof( what ) ) ensure( alias what )()
196 {
197     return Arg!( typeof( what ) )( nameof!what, what );
198 }
199 
200 /++
201 Ensures that the argument is not null.
202 
203 Params:
204     arg = A wrapped argument, obtained with ensure.
205 
206 See_Also: ensure
207 Throws: EnsureException when validation fails.
208 Authors: Tony J. Hudgins
209 Copyright: Copyright © 2019, Tony J. Hudgins
210 License: MIT
211 +/
212 Arg!T isNotNull( T )( Arg!T arg ) if( isNullable!T )
213 {
214     if( arg.value is null )
215         arg.throwWith( "argument cannot be null" );
216 
217     return arg;
218 }
219 
220 @system unittest
221 {
222     string a = null;
223     string b = "";
224 
225     mustThrow!EnsureException( { ensure!a.isNotNull; } );
226 
227     ensure!b.isNotNull;
228 }
229 
230 /++
231 Ensures that the std.typecons.Nullable is not in a null state.
232 
233 Params:
234     arg = A wrapped argument, obtained with ensure.
235 
236 See_Also: ensure
237 Throws: EnsureException when validation fails.
238 Authors: Tony J. Hudgins
239 Copyright: Copyright © 2019, Tony J. Hudgins
240 License: MIT
241 +/
242 Arg!T isNotNull( T: Nullable!U, U )( Arg!T arg )
243 {
244     if( arg.value.isNull )
245         arg.throwWith( "nullable cannot be null" );
246 
247     return arg;
248 }
249 
250 @system unittest
251 {
252     import std.typecons : Nullable, nullable;
253 
254     Nullable!int a;
255     auto b = nullable( 5 );
256 
257     mustThrow!EnsureException( { ensure!a.isNotNull; } );
258     ensure!b.isNotNull;
259 }
260 
261 /++
262 Ensures that the std.typecons.NullableRef is not in a null state.
263 
264 Params:
265     arg = A wrapped argument, obtained with ensure.
266 
267 See_Also: ensure
268 Throws: EnsureException when validation fails.
269 Authors: Tony J. Hudgins
270 Copyright: Copyright © 2019, Tony J. Hudgins
271 License: MIT
272 +/
273 Arg!T isNotNull( T: NullableRef!U, U )( Arg!T arg )
274 {
275     if( arg.value.isNull )
276         arg.throwWith( "nullable cannot be null" );
277 
278     return arg;
279 }
280 
281 @system unittest
282 {
283     import std.typecons : NullableRef, nullableRef;
284 
285     int naked = 5;
286 
287     NullableRef!int a;
288     auto b = nullableRef( &naked );
289 
290     mustThrow!EnsureException( { ensure!a.isNotNull; } );
291 
292     ensure!b.isNotNull;
293 }
294 
295 /++
296 Ensures that an array, associative array, string, or input range is not empty.
297 
298 Params:
299     arg = A wrapped argument, obtained with ensure.
300 
301 See_Also: ensure
302 Throws: EnsureException when validation fails.
303 Authors: Tony J. Hudgins
304 Copyright: Copyright © 2019, Tony J. Hudgins
305 License: MIT
306 +/
307 Arg!T isNotEmpty( T )( Arg!T arg ) if( isInputRange!T || isSomeString!T || isArray!T || isAssociativeArray!T )
308 {
309     static if( isInputRange!T )
310         enum kind = "range";
311     else static if( isSomeString!T )
312         enum kind = "string";
313     else static if( isArray!T )
314         enum kind = "array";
315     else static if( isAssociativeArray!T )
316         enum kind = "associative array";
317     else
318         static assert( false, "unsupported type for isNotEmpty: " ~ T.stringof );
319 
320     static if( isInputRange!T && !isSomeString!T && !isArray!T )
321     {
322         if( arg.value.empty )
323             arg.throwWith( kind ~ " cannot be empty" );
324     }
325     else
326     {
327         if( arg.value.length == 0 )
328             arg.throwWith( kind ~ " cannot be empty" );
329     }
330 
331     return arg;
332 }
333 
334 @system unittest
335 {
336     import std.range : iota;
337     import std.algorithm : map;
338 
339     string str = "1";
340     int[] arr = [ 1 ];
341     int[string] assoc = [ "one": 1 ];
342     auto some = iota( 0, 5, 1 );
343     auto none = iota( 0, 0, 0 );
344 
345     ensure!str.isNotEmpty;
346     ensure!arr.isNotEmpty;
347     ensure!assoc.isNotEmpty;
348 
349     str = "";
350     arr = [];
351     assoc = typeof( assoc ).init;
352 
353     mustThrow!EnsureException( { ensure!str.isNotEmpty; } );
354     mustThrow!EnsureException( { ensure!arr.isNotEmpty; } );
355     mustThrow!EnsureException( { ensure!assoc.isNotEmpty; } );
356 
357     ensure!(some).isNotEmpty;
358 
359     mustThrow!EnsureException( { ensure!(none).isNotEmpty; } );
360 }
361 
362 /++
363 Ensures that a string does not contain only whitespace characters.
364 
365 Params:
366     arg = A wrapped argument, obtained with ensure.
367 
368 See_Also: ensure
369 Throws: EnsureException when validation fails.
370 Authors: Tony J. Hudgins
371 Copyright: Copyright © 2019, Tony J. Hudgins
372 License: MIT
373 +/
374 Arg!S isNotWhitespace( S )( Arg!S arg ) if( isSomeString!S )
375 {
376     import std.algorithm : all;
377     import std.uni : isWhite;
378 
379     if( arg.isNotNull.value.all!isWhite )
380         arg.throwWith( "string cannot consist of only whitespace characters" );
381 
382     return arg;
383 }
384 
385 @system unittest
386 {
387     auto a = "123";
388     auto b = "   ";
389 
390     ensure!a.isNotWhitespace;
391 
392     mustThrow!EnsureException( { ensure!b.isNotWhitespace; } );
393 }
394 
395 /++
396 Ensures that a number is between an upper bound and a lower bound.
397 
398 By default, this function's bounds are **inclusive**, inclusivity/exclusivity can be changed
399 on a per-bound basis by passing in a string template argument similar to std.random.uniform.
400 
401 The string template parameter expects a 2-character long string consisting of an
402 opening parenthesis or opening square bracket, followed by a closing parenthesis or closing square bracket.
403 The opening bracket is for the lower bound, while the closing bracket is for the upper bound.
404 Parenteses indicate that the boundary is inclusive, and square brackets indicate the boundary is exclusive.
405 
406 All possible combinations:
407 
408 - **()** - Both lower bound and upper bound are inclusive.
409 - **(]** - Lower bound is inclusive and upper bound is exclusive.
410 - **[)** - Lower bound is exclusive and upper bound is inclusive.
411 - **[]** - Both lower bound and upper bound are exclusive.
412 
413 Params:
414     arg = A wrapped argument, obtained with ensure.
415 
416 Examples:
417 ---------
418 const zero = 0;
419 
420 ensure!(zero).between!"[)"( 0, 5 ); // exception, lower bound is exclusive
421 ---------
422 
423 See_Also: ensure
424 Throws: EnsureException when validation fails.
425 Authors: Tony J. Hudgins
426 Copyright: Copyright © 2019, Tony J. Hudgins
427 License: MIT
428 +/
429 Arg!N between( string how = "()", N )( Arg!N arg, N lowerBound, N upperBound )
430     if( isNumeric!N && isValidRangeSpecifier!how )
431 {
432     enum lowerOp = how[0] == '(' ? "<" : "<=";
433     enum upperOp = how[1] == ')' ? ">" : ">=";
434 
435     static enum Bound { lower, upper }
436 
437     string err( Bound bound, bool inclusive, N value )
438     {
439         import std.array : appender;
440 
441         auto msg = appender!string;
442         msg.put( "argument (%s) is ".format( arg.value ) );
443 
444         with( Bound )
445         final switch( bound )
446         {
447             case lower:
448                 msg.put( "less than " );
449                 break;
450 
451             case upper:
452                 msg.put( "greater than " );
453                 break;
454         }
455 
456         if( !inclusive )
457             msg.put( "or equal to " );
458 
459         msg.put( "the " );
460 
461         with( Bound )
462         final switch( bound )
463         {
464             case lower:
465                 msg.put( "lower " );
466                 break;
467 
468             case upper:
469                 msg.put( "upper " );
470                 break;
471         }
472 
473         msg.put( "bound (%s)".format( value ) );
474 
475         return msg.data;
476     }
477 
478     if( mixin( "arg.value" ~ lowerOp ~ "lowerBound" ) )
479         arg.throwWith( err( Bound.lower, how[0] == '(', lowerBound ) );
480 
481     if( mixin( "arg.value" ~ upperOp ~ "upperBound" ) )
482         arg.throwWith( err( Bound.upper, how[1] == ')', upperBound ) );
483 
484     return arg;
485 }
486 
487 @system unittest
488 {
489     auto lower = 0;
490     auto upper = 5;
491 
492     ensure!(lower).between!"()"( lower, upper );
493     ensure!(upper).between!"()"( lower, upper );
494 
495     mustThrow!EnsureException( { ensure!(lower).between!"[]"( lower, upper ); } );
496     mustThrow!EnsureException( { ensure!(upper).between!"[]"( lower, upper ); } );
497 }
498 
499 // auto-generate some functions for common boolean operations.
500 private enum ops = [
501     ">": [ "greaterThan", "gt" ],
502     ">=": [ "greaterThanOrEqualTo", "gte" ],
503 
504     "<": [ "lessThan", "lt" ],
505     "<=": [ "lessThanOrEqualTo", "lte" ],
506 
507     "==": [ "equalTo", "eq" ],
508     "!=": [ "notEqualTo", "ne", "neq" ],
509 ];
510 
511 static foreach( op, names; ops )
512 static foreach( name; names )
513 {
514     mixin( `
515         Arg!T %01$s( T )( Arg!T arg, T value ) if( supportsOperation!( T, "%02$s" ) )
516         {
517             if( !( arg.value %02$s value ) )
518                 arg.throwWith(
519                     "argument (%%01$s) does not match expression (%%01$s %02$s %%02$s)"
520                     .format( arg.value, value )
521                 );
522 
523             return arg;
524         }
525     `.format( name, op ) );
526 }