1 module dshould.ShouldType;
2 
3 import std.algorithm : map;
4 import std.format : format;
5 import std.meta : allSatisfy;
6 import std.range : iota;
7 import std..string : empty, join;
8 import std.traits : TemplateArgsOf;
9 public import std.traits : isInstanceOf;
10 import std.typecons : Tuple;
11 
12 // prevent default arguments from being accidentally filled by regular parameters
13 // void foo(..., Fence _ = Fence(), string file = __FILE__, size_t line = __LINE__)
14 public struct Fence
15 {
16 }
17 
18 /**
19  * .should begins every fluent assertion in dshould. It takes no parameters on its own.
20  * Note that leaving a .should phrase unfinished will error at runtime.
21  */
22 public auto should(T)(lazy T got) pure
23 {
24     T get() pure @safe
25     {
26         return got;
27     }
28 
29     return ShouldType!(typeof(&get))(&get);
30 }
31 
32 /**
33  * ShouldType is the base type passed between UFCS words in fluent assertions.
34  * It stores the left-hand side expression of the phrase, called "got" in errors,
35  * as well as the words making up the assertion phrase as template arguments.
36  */
37 public struct ShouldType(G, string[] phrase = [])
38 {
39     import std.algorithm : canFind;
40 
41     public G got;
42 
43     private int* refCount_ = null;
44 
45     /**
46      * Add a word to the phrase. Can be chained.
47      */
48     public auto addWord(string word)()
49     {
50         return ShouldType!(G, phrase ~ word)(this.got, this.refCount);
51     }
52 
53     private this(G got) { this.got = got; this.refCount = 1; }
54 
55     /**
56      * Manually initialize a new ShouldType value from an existing one's ref count.
57      * All ShouldTypes of one phrase must use the same reference counter.
58      */
59     public this(G got, ref int refCount) @trusted
60     in
61     {
62         assert(refCount != CHAIN_TERMINATED, "don't copy Should that's been terminated");
63     }
64     do
65     {
66         this.got = got;
67         this.refCount_ = &refCount;
68         this.refCount++;
69     }
70 
71     this(this) @trusted
72     in
73     {
74         assert(this.refCount != CHAIN_TERMINATED);
75     }
76     do
77     {
78         this.refCount++;
79     }
80 
81     ~this() @trusted
82     {
83         this.refCount--;
84         assert(this.refCount > 0, "unterminated should-chain!");
85     }
86 
87     /**
88      * Generate a mixin that checks a format expression for truth,
89      * throwing an exception with a detailed message when it fails.
90      * Params:
91      *   TestString = D code representing the expression to be tested as a string.
92      *                %s is used for variables.
93      *   ArgNames = The names of the local variables to be substituted for the format keys.
94      */
95     public static enum genCheck(string TestString, string[] ArgNames) = genCheckImpl(TestString, ArgNames);
96 
97     private static string genCheckImpl(string testString, string[] argNames)
98     {
99         auto args = format!"%-(%s, %)"(argNames);
100         auto argStrings = format!"%(%s, %)"(argNames);
101 
102         return format!q{{
103             import std.format : format;
104 
105             check(mixin(format!q{%s}(%s)), format(": expected %s", %s), null, file, line);
106         }}(testString, argStrings, testString, args);
107     }
108 
109     /**
110      * Checks a boolean condition for truth, throwing an exception when it fails.
111      * The components making up the exception string are passed lazily.
112      * The message has the form: "test failed {left} [because reason] {right}"
113      * For instance: "test failed: expected got.empty() because there should be nothing in there, but got [5]."
114      * In that case, left is ": expected got.empty()" and right is "but got [5]".
115      */
116     public void check(bool condition, lazy string left, lazy string right, string file, size_t line) pure @safe
117     {
118         terminateChain;
119 
120         if (!condition)
121         {
122             throw new FluentException("test failed" ~ left, right, file, line);
123         }
124     }
125 
126     /**
127      * Mark that the semantic end of this phrase has been reached.
128      * If this is not called, the phrase will error on scope exit.
129      */
130     public void terminateChain()
131     {
132         this.refCount = CHAIN_TERMINATED; // terminate chain, safe ref checker
133     }
134 
135     private static enum isStringLiteral(T...) = T.length == 1 && is(typeof(T[0]) == string);
136 
137     /**
138      * Allows to check that only a select list of words are permitted before the current word.
139      * On failure, an informative error is printed.
140      * Usage: should.allowOnlyWords!("word1", "word2").before!"newWord";
141      */
142     public template allowOnlyWords(allowedWords...)
143     if (allSatisfy!(isStringLiteral, allowedWords))
144     {
145         void before(string newWord)()
146         {
147             static foreach (word; phrase)
148             {
149                 static assert([allowedWords].canFind(word), `bad grammar: "` ~ word ~ ` ` ~ newWord ~ `"`);
150             }
151         }
152     }
153 
154     /**
155      * Allows to check that a specified word appeared in the phrase before the current word.
156      * On failure, an informative error is printed.
157      * Usage: should.requireWord!"word".before!"newWord";
158      */
159     public template requireWord(string requiredWord)
160     {
161         void before(string newWord)()
162         {
163             static assert(
164                 hasWord!requiredWord,
165                 `bad grammar: expected "` ~ requiredWord ~ `" before "` ~ newWord ~ `"`
166             );
167         }
168     }
169 
170     /**
171      * Evaluates to true if the given word exists in the current phrase.
172      */
173     public enum hasWord(string word) = phrase.canFind(word);
174 
175     // work around https://issues.dlang.org/show_bug.cgi?id=18839
176     public auto empty()() { return this.empty_; }
177 
178     private enum CHAIN_TERMINATED = int.max;
179 
180     private @property ref int refCount()
181     {
182         if (this.refCount_ is null)
183         {
184             this.refCount_ = new int;
185             *this.refCount_ = 0;
186         }
187         return *this.refCount_;
188     }
189 }
190 
191 /**
192  * Ensures that the given range is empty.
193  * Specified here due to https://issues.dlang.org/show_bug.cgi?id=18839
194  */
195 public void empty_(Should)(Should should, Fence _ = Fence(), string file = __FILE__, size_t line = __LINE__)
196 if (isInstanceOf!(ShouldType, Should))
197 {
198     import std.range : empty;
199 
200     with (should)
201     {
202         allowOnlyWords!("be", "not").before!"empty";
203         requireWord!"be".before!"empty";
204 
205         terminateChain;
206 
207         auto got = should.got();
208 
209         static if (hasWord!"not")
210         {
211             check(!got.empty, `: expected nonempty range`, null, file, line);
212         }
213         else
214         {
215             check(got.empty, `: expected empty range`, format(", but got %s", got), file, line);
216         }
217     }
218 }
219 
220 ///
221 unittest
222 {
223     import dshould.basic;
224 
225     [].should.be.empty;
226     [5].should.not.be.empty;
227 }
228 
229 private class FluentExceptionImpl(T : Exception) : T
230 {
231     private const string leftPart = null; // before reason
232     public const string reason = null;
233     private const string rightPart = null; // after reason
234 
235     invariant
236     {
237         assert(!this.leftPart.empty);
238     }
239 
240     public this(string leftPart, string rightPart, string file, size_t line) pure @safe
241     in
242     {
243         assert(!leftPart.empty);
244     }
245     do
246     {
247         this.leftPart = leftPart;
248         this.rightPart = rightPart;
249 
250         super(combinedMessage, file, line);
251     }
252 
253     public this(string leftPart, string reason, string rightPart, string file, size_t line) pure @safe
254     in
255     {
256         assert(!leftPart.empty);
257     }
258     do
259     {
260         this.leftPart = leftPart;
261         this.reason = reason;
262         this.rightPart = rightPart;
263 
264         super(combinedMessage, file, line);
265     }
266 
267     public this(string msg, string file, size_t line) pure @safe
268     in
269     {
270         assert(!msg.empty);
271     }
272     do
273     {
274         this.leftPart = msg;
275 
276         super(combinedMessage, file, line);
277     }
278 
279     public FluentException because(string reason) pure @safe
280     {
281         return new FluentException(this.leftPart, reason, this.rightPart, this.file, this.line);
282     }
283 
284     private @property string combinedMessage() pure @safe
285     {
286         string message = this.leftPart;
287 
288         if (!this.reason.empty)
289         {
290             message ~= format!` because %s`(this.reason);
291         }
292 
293         if (!this.rightPart.empty)
294         {
295             message ~= this.rightPart;
296         }
297 
298         return message;
299     }
300 }
301 
302 /**
303  * When a fluent exception is thrown during the evaluation of the left-hand side of this word,
304  * then the reason for the test is set to the `reason` parameter.
305  *
306  * Usage: 2.should.be(2).because("math is sane");
307  */
308 public T because(T)(lazy T value, string reason)
309 {
310     try
311     {
312         return value;
313     }
314     catch (FluentException fluentException)
315     {
316         throw fluentException.because(reason);
317     }
318 }
319 
320 static if (__traits(compiles, { import unit_threaded.should : UnitTestException; }))
321 {
322     import unit_threaded.should : UnitTestException;
323 
324     /**
325      * Indicates a fluent assert has failed, as well as what was tested, why it was tested, and what the outcome was.
326      * When unit_threaded is provided, FluentException is a unit_threaded test exception.
327      */
328     public alias FluentException = FluentExceptionImpl!UnitTestException;
329 }
330 else
331 {
332     public alias FluentException = FluentExceptionImpl!Exception;
333 }